--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 495de05eb27291262439a8a47cd7942863f83b65
Parents : 464cfa5
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-02T16:22:29-05:00
feat(micron): implement SRI verification for WASM assets and generate integrity.json
Changes
7 files changed, 213 insertions(+), 19 deletions(-)
Diff
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7d82d7b2..e1568910 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -45,6 +45,8 @@ export default [
...globals.browser,
...globals.node,
__APP_BUILD_TIME__: "readonly",
+ __MICRON_WASM_SRI_WASM__: "readonly",
+ __MICRON_WASM_SRI_EXEC__: "readonly",
axios: "readonly",
Codec2Lib: "readonly",
Codec2MicrophoneRecorder: "readonly",
diff --git a/meshchatx/src/frontend/js/MicronWasmLoader.js b/meshchatx/src/frontend/js/MicronWasmLoader.js
index 5655d4be..36fb9e3d 100644
--- a/meshchatx/src/frontend/js/MicronWasmLoader.js
+++ b/meshchatx/src/frontend/js/MicronWasmLoader.js
@@ -5,6 +5,14 @@
*/
let resolvedPromise = null;
+let integrityHashes = null;
+
+/** Computes SHA-384 hash of ArrayBuffer for SRI verification. */
+async function computeSriHash(buf) {
+ const hash = await crypto.subtle.digest("SHA-384", buf);
+ const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
+ return `sha384-${base64}`;
+}
/** Injects CSS required for ForceMonospace mode when using WASM. Safe to call multiple times. */
function injectMicronWasmStyles() {
@@ -46,18 +54,69 @@ function baseUrl() {
return `${root.replace(/\/?$/, "/")}vendor/micron-parser-go`;
}
-function injectScript(src) {
+/** Returns SRI hashes embedded at build time (primary) or fetched from integrity.json (fallback). */
+async function getIntegrityHashes() {
+ if (integrityHashes !== null) {
+ return integrityHashes;
+ }
+ // Use build-time embedded hashes as primary trusted source (tamper-proof)
+ const embeddedWasm = typeof __MICRON_WASM_SRI_WASM__ !== "undefined" ? __MICRON_WASM_SRI_WASM__ : "";
+ const embeddedExec = typeof __MICRON_WASM_SRI_EXEC__ !== "undefined" ? __MICRON_WASM_SRI_EXEC__ : "";
+ if (embeddedWasm && embeddedExec) {
+ integrityHashes = { wasm: embeddedWasm, wasmExec: embeddedExec };
+ return integrityHashes;
+ }
+ // Fallback: fetch from server (less secure, but allows development builds)
+ try {
+ const res = await fetch(`${baseUrl()}/integrity.json`);
+ if (!res.ok) return null;
+ integrityHashes = await res.json();
+ return integrityHashes;
+ } catch {
+ return null;
+ }
+}
+
+/** Verifies SRI hash of buffer against expected hash. Throws if mismatch or no hash provided. */
+async function verifySri(buf, expectedHash, name) {
+ if (!expectedHash) {
+ throw new Error(`Micron WASM: SRI hash missing for ${name}. Refusing to load untrusted code.`);
+ }
+ const actualHash = await computeSriHash(buf);
+ if (actualHash !== expectedHash) {
+ throw new Error(
+ `Micron WASM: SRI hash mismatch for ${name}. Possible tampering detected. Refusing to execute.`
+ );
+ }
+}
+
+async function injectScript(src, expectedHash) {
const id = "meshchatx-micron-wasm-exec";
if (document.getElementById(id)) {
- return Promise.resolve();
+ return;
+ }
+ // Fetch and verify SRI before injecting
+ const res = await fetch(src);
+ if (!res.ok) {
+ throw new Error(`Micron WASM: failed to fetch script ${src} (${res.status})`);
}
+ const buf = await res.arrayBuffer();
+ await verifySri(buf, expectedHash, "wasm_exec.js");
+ const blob = new Blob([buf], { type: "application/javascript" });
+ const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.id = id;
s.async = true;
- s.src = src;
- s.onload = () => resolve();
- s.onerror = () => reject(new Error(`Micron WASM: failed to load script ${src}`));
+ s.src = blobUrl;
+ s.onload = () => {
+ URL.revokeObjectURL(blobUrl);
+ resolve();
+ };
+ s.onerror = () => {
+ URL.revokeObjectURL(blobUrl);
+ reject(new Error(`Micron WASM: failed to load script ${src}`));
+ };
document.head.appendChild(s);
});
}
@@ -67,7 +126,9 @@ async function instantiateOnce() {
throw new Error("Micron WASM: WebAssembly is not available");
}
const root = baseUrl();
- await injectScript(`${root}/wasm_exec.js`);
+ const integrity = await getIntegrityHashes();
+
+ await injectScript(`${root}/wasm_exec.js`, integrity?.wasmExec);
if (typeof globalThis.Go === "undefined") {
throw new Error("Micron WASM: Go runtime missing after wasm_exec.js load");
}
@@ -75,14 +136,26 @@ async function instantiateOnce() {
const go = new globalThis.Go();
let result;
try {
- result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject);
+ // Try streaming first
+ const res = await fetch(wasmUrl);
+ if (!res.ok) {
+ throw new Error(`Micron WASM: fetch failed (${res.status})`);
+ }
+ const buf = await res.arrayBuffer();
+ await verifySri(buf, integrity?.wasm, "micron-parser-go.wasm");
+ result = await WebAssembly.instantiateStreaming(
+ new Response(buf, { headers: { "content-type": "application/wasm" } }),
+ go.importObject
+ );
} catch {
+ // Fallback to buffer instantiation
const buf = await fetch(wasmUrl).then((r) => {
if (!r.ok) {
throw new Error(`Micron WASM: fetch failed (${r.status})`);
}
return r.arrayBuffer();
});
+ await verifySri(buf, integrity?.wasm, "micron-parser-go.wasm");
result = await WebAssembly.instantiate(buf, go.importObject);
}
go.run(result.instance);
diff --git a/meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json b/meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json
new file mode 100644
index 00000000..cea4ebcc
--- /dev/null
+++ b/meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json
@@ -0,0 +1,5 @@
+{
+ "version": "v1.0.3",
+ "wasm": "sha384-UTy7QMULnY7BvB4K66L00C+5o23/jZVGd9klogT4FFU7tU1AxwkLRXZYDiLeTdP+",
+ "wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce"
+}
\ No newline at end of file
diff --git a/scripts/fetch-micron-wasm.mjs b/scripts/fetch-micron-wasm.mjs
index 48978dac..b0fe5beb 100644
--- a/scripts/fetch-micron-wasm.mjs
+++ b/scripts/fetch-micron-wasm.mjs
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* Downloads micron-parser-go WASM release assets and matching wasm_exec.js for Vite public/.
+ * Generates SRI hashes for integrity verification at runtime.
* Safe to run offline: exits 0 without files when MICRON_WASM_SKIP=1 or network fails.
*
* Override URLs:
@@ -9,6 +10,7 @@
*/
import fs from "fs";
import path from "path";
+import crypto from "crypto";
import { MICRON_PARSER_GO_RELEASE_TAG } from "./micron-parser-go-version.mjs";
import { micronWasmVendorPaths, micronWasmRepoRoot } from "./micron-wasm-resolve-bundled.mjs";
@@ -25,6 +27,11 @@ function rmQuiet(p) {
}
}
+function computeSriHash(buf) {
+ const hash = crypto.createHash("sha384").update(buf).digest("base64");
+ return `sha384-${hash}`;
+}
+
async function fetchBinary(url, destFile) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
@@ -36,7 +43,7 @@ async function fetchBinary(url, destFile) {
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(destFile), { recursive: true });
fs.writeFileSync(destFile, buf);
- return buf.length;
+ return { size: buf.length, sri: computeSriHash(buf) };
} finally {
clearTimeout(t);
}
@@ -55,18 +62,30 @@ async function main() {
fs.mkdirSync(dir, { recursive: true });
+ let wasmResult;
+ let execResult;
try {
console.log("fetch-micron-wasm: downloading wasm_exec.js...");
- await fetchBinary(execUrl, wasmExec);
+ execResult = await fetchBinary(execUrl, wasmExec);
console.log("fetch-micron-wasm: downloading micron-parser-go.wasm...");
- const n = await fetchBinary(wasmUrl, wasm);
- console.log(`fetch-micron-wasm: OK (${n} bytes WASM)`);
+ wasmResult = await fetchBinary(wasmUrl, wasm);
+ console.log(`fetch-micron-wasm: OK (${wasmResult.size} bytes WASM)`);
} catch (e) {
console.warn("fetch-micron-wasm: failed:", e?.message || e);
rmQuiet(wasm);
rmQuiet(wasmExec);
+ rmQuiet(path.join(dir, "integrity.json"));
process.exit(0);
}
+
+ // Write SRI hashes for runtime verification
+ const integrity = {
+ version: MICRON_PARSER_GO_RELEASE_TAG,
+ wasm: wasmResult.sri,
+ wasmExec: execResult.sri,
+ };
+ fs.writeFileSync(path.join(dir, "integrity.json"), JSON.stringify(integrity, null, 2));
+ console.log("fetch-micron-wasm: SRI hashes written to integrity.json");
}
main();
diff --git a/tests/frontend/MicronWasmLoader.test.js b/tests/frontend/MicronWasmLoader.test.js
index 001ccb4a..17d44901 100644
--- a/tests/frontend/MicronWasmLoader.test.js
+++ b/tests/frontend/MicronWasmLoader.test.js
@@ -100,10 +100,35 @@ describe("MicronWasmLoader.js", () => {
return node;
});
- vi.spyOn(globalThis, "fetch").mockResolvedValue({
- ok: true,
- arrayBuffer: async () => new ArrayBuffer(16),
- headers: new Headers({ "content-type": "application/wasm" }),
+ // Mock crypto.subtle.digest to return hash matching embedded SRI for test data
+ const mockWasmHash = __MICRON_WASM_SRI_WASM__?.replace("sha384-", "") || "";
+ const mockExecHash = __MICRON_WASM_SRI_EXEC__?.replace("sha384-", "") || "";
+ vi.stubGlobal("crypto", {
+ subtle: {
+ digest: vi.fn(async (algo, buf) => {
+ // Return different hash based on buffer size to distinguish wasm_exec.js vs wasm
+ const hash =
+ buf.byteLength < 1000
+ ? mockExecHash // wasm_exec.js is smaller
+ : mockWasmHash; // wasm is larger
+ // Convert base64 to ArrayBuffer
+ const binary = atob(hash);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }),
+ },
+ });
+
+ vi.spyOn(globalThis, "fetch").mockImplementation((url) => {
+ const isWasm = url.includes(".wasm");
+ return Promise.resolve({
+ ok: true,
+ arrayBuffer: async () => (isWasm ? new ArrayBuffer(4096) : new ArrayBuffer(500)),
+ headers: new Headers({ "content-type": isWasm ? "application/wasm" : "application/javascript" }),
+ });
});
const streaming = vi
@@ -118,6 +143,7 @@ describe("MicronWasmLoader.js", () => {
expect(instantiate).toHaveBeenCalled();
} finally {
appendSpy.mockRestore();
+ vi.unstubAllGlobals();
}
});
@@ -137,10 +163,30 @@ describe("MicronWasmLoader.js", () => {
return node;
});
- vi.spyOn(globalThis, "fetch").mockResolvedValue({
- ok: true,
- arrayBuffer: async () => new ArrayBuffer(16),
- headers: new Headers({ "content-type": "application/wasm" }),
+ // Mock crypto.subtle.digest to return hash matching embedded SRI for test data
+ const mockWasmHash = __MICRON_WASM_SRI_WASM__?.replace("sha384-", "") || "";
+ const mockExecHash = __MICRON_WASM_SRI_EXEC__?.replace("sha384-", "") || "";
+ vi.stubGlobal("crypto", {
+ subtle: {
+ digest: vi.fn(async (algo, buf) => {
+ const hash = buf.byteLength < 1000 ? mockExecHash : mockWasmHash;
+ const binary = atob(hash);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }),
+ },
+ });
+
+ vi.spyOn(globalThis, "fetch").mockImplementation((url) => {
+ const isWasm = url.includes(".wasm");
+ return Promise.resolve({
+ ok: true,
+ arrayBuffer: async () => (isWasm ? new ArrayBuffer(4096) : new ArrayBuffer(500)),
+ headers: new Headers({ "content-type": isWasm ? "application/wasm" : "application/javascript" }),
+ });
});
vi.spyOn(WebAssembly, "instantiateStreaming").mockRejectedValue(new Error("streaming failed"));
@@ -156,6 +202,7 @@ describe("MicronWasmLoader.js", () => {
expect(instantiate).toHaveBeenCalledTimes(2);
} finally {
appendSpy.mockRestore();
+ vi.unstubAllGlobals();
}
});
diff --git a/vite.config.js b/vite.config.js
index ceab4aa1..94360afa 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -51,11 +51,36 @@ function isMicronWasmBundledResolved() {
const micronWasmBundled = isMicronWasmBundledResolved();
+function loadMicronWasmIntegrity() {
+ if (!micronWasmBundled) return null;
+ const integrityPath = path.join(
+ __dirname,
+ "meshchatx",
+ "src",
+ "frontend",
+ "public",
+ "vendor",
+ "micron-parser-go",
+ "integrity.json"
+ );
+ try {
+ const content = fs.readFileSync(integrityPath, "utf-8");
+ return JSON.parse(content);
+ } catch {
+ console.warn("vite: could not load micron-parser-go integrity.json");
+ return null;
+ }
+}
+
+const micronWasmIntegrity = loadMicronWasmIntegrity();
+
export default defineConfig({
define: {
__APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso),
"import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"),
"import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG),
+ __MICRON_WASM_SRI_WASM__: JSON.stringify(micronWasmIntegrity?.wasm || ""),
+ __MICRON_WASM_SRI_EXEC__: JSON.stringify(micronWasmIntegrity?.wasmExec || ""),
},
plugins: [
tailwindcss(),
diff --git a/vitest.config.js b/vitest.config.js
index 973f6d55..f7d534a8 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -20,6 +20,27 @@ function isMicronWasmBundledResolved(repoRoot) {
const micronWasmBundled = isMicronWasmBundledResolved(__dirname);
+function loadMicronWasmIntegrity(repoRoot) {
+ if (!micronWasmBundled) return null;
+ const integrityPath = path.join(
+ repoRoot,
+ "meshchatx",
+ "src",
+ "frontend",
+ "public",
+ "vendor",
+ "micron-parser-go",
+ "integrity.json"
+ );
+ try {
+ const content = fs.readFileSync(integrityPath, "utf-8");
+ return JSON.parse(content);
+ } catch {
+ return null;
+ }
+}
+
+const micronWasmIntegrity = loadMicronWasmIntegrity(__dirname);
const appBuildTimeIso = new Date().toISOString();
export default defineConfig({
@@ -27,6 +48,8 @@ export default defineConfig({
__APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso),
"import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"),
"import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG),
+ __MICRON_WASM_SRI_WASM__: JSON.stringify(micronWasmIntegrity?.wasm || ""),
+ __MICRON_WASM_SRI_EXEC__: JSON.stringify(micronWasmIntegrity?.wasmExec || ""),
},
plugins: [
vue({
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────